library(plyr)
library(tidyverse)
library(tidytext)13 Эмбеддинги на основе PMI-матрицы
Как упоминалось в предыдущем уроке, векторная модель может основываться не на матрице термин-документ, а на матрице термин-термин. В этом случае моделируется именно пространство слов: информацию о документах мы при этом теряем (хотя это поправимо).
Для удобства сравнения двух подходов мы возьмем тот же новостной датасет, которым пользовались для построения LSA модели.
load("../data/news_tokens_pruned.Rdata")
news_tokens_pruned 13.1 Скользящее окно
Прежде всего разделим новости на контекстные окна фиксированной величины. Чем меньше окно, тем больше синтаксической информации оно хранит.
library(tidyr)
nested_news <- news_tokens_pruned |>
dplyr::select(-topic) |>
nest(tokens = c(token))
nested_newsslide_windows <- function(tbl, window_size) {
skipgrams <- slider::slide(
tbl,
~.x,
.after = window_size - 1,
.step = 1,
.complete = TRUE
)
safe_mutate <- safely(mutate)
out <- map2(skipgrams,
1:length(skipgrams),
~ safe_mutate(.x, window_id = .y))
out %>%
transpose() %>%
pluck("result") %>%
compact() %>%
bind_rows()
}Деление на окна может потребовать нескольких минут. Чем больше окно, тем больше потребуется времени и тем больше будет размер таблицы.
news_windows <- nested_news |>
mutate(tokens = map(tokens, slide_windows, 10L)) %>%
unnest(tokens) %>%
unite(window_id, id, window_id)
news_windowsload("../data/news_windows.Rdata")13.2 Что такое PMI
Обычная мера ассоциации между словами, которой пользуются лингвисты, — точечная взаимная информация, или PMI (pointwise mutual information). Она рассчитывается по формуле:
\[PMI\left(x;y\right)=\log{\frac{P\left(x,y\right)}{P\left(x\right)P\left(y\right)}}\]
В числителе — вероятность встретить два слова вместе (например, в пределах одного документа или одного «окна» длинной n слов). В знаменателе — произведение вероятностей встретить каждое из слов отдельно. Если слова чаще встречаются вместе, логарифм будет положительным; если по отдельности — отрицательным.
Посчитаем PMI на наших данных, воспользовавшись подходящей функцией из пакета widyr.
library(widyr)
news_pmi <- news_windows |>
pairwise_pmi(token, window_id)news_pmi |>
arrange(-abs(pmi))13.3 Почему PPMI
В отличие от коэффициента корреляции, например, PMI может варьироваться от \(-\infty\) до \(+\infty\), но негативные значения проблематичны. Они означают, что вероятность встретить эти два слова вместе меньше, чем мы бы ожидали в результате случайного совпадения. Проверить это без огромного корпуса невозможно: если у нас есть \(w_1\) и \(w_2\), каждое из которых встречается с вероятностью \(10^{-6}\), то трудно удостовериться в том, что \(p(w_1, w_2)\) значимо отличается от \(10^{-12}\). Поэтому негативные значения PMI принято заменять нулями. В таком случае формула выглядит так:
\[ PMI\left(x;y\right)=max(\log{\frac{P\left(x,y\right)}{P\left(x\right)P\left(y\right)}},0) \] Для подобной замены подойдет векторизованное условие.
news_ppmi <- news_pmi |>
mutate(ppmi = case_when(pmi < 0 ~ 0,
.default = pmi))
news_ppmi |>
arrange(pmi)Если мы развернем такую матрицу вширь, то она получится очень разреженной; чтобы получить плотные векторы слов, необходимо прибегнуть к SVD.
13.4 SVD на матрице с PPMI
Для этого можно передать тиббл фунции widely_svd() для вычисления сингулярного разложения. Обратите внимание на аргумент weight_d: если задать ему значение FALSE, то вернутся не эмбеддинги, а матрица левых сингулярных векторов:
word_emb <- news_ppmi |>
widely_svd(item1, item2, ppmi,
weight_d = FALSE, nv = 100) |>
rename(word = item1) # иначе nearest_neighbors() будет жаловатьсяword_emb13.5 Визуализация топиков
Визуализируем главные компоненты нашего векторного пространства.
word_emb |>
filter(dimension < 10) |>
group_by(dimension) |>
top_n(10, abs(value)) |>
ungroup() |>
mutate(word = reorder_within(word, value, dimension)) |>
ggplot(aes(word, value, fill = dimension)) +
geom_col(alpha = 0.8, show.legend = FALSE) +
facet_wrap(~dimension, scales = "free_y", ncol = 3) +
scale_x_reordered() +
coord_flip() +
labs(
x = NULL,
y = "Value",
title = "Первые 9 главных компонент за 2019 г.",
subtitle = "Топ-10 слов"
) +
scale_fill_viridis_c()
13.6 Ближайшие соседи
Исследуем наши эмбеддинги, используя уже знакомую функцию, которая считает косинусное сходство между словами.
source("../helper_scripts/nearest_neighbors.R")word_emb |>
nearest_neighbors("сборная")word_emb |>
nearest_neighbors("завод")13.7 От эмбеддингов слов к эмбеддингам документов
Используя документы как суммы входящих в них слов, мы может использовать эмбеддинги для нахождения ближайших документов в корпусе.
Первая матрица хранит информацию о встречаемости слов в документах; в рядах здесь документы; в столбцах – слова (всего 6299).
counts_mx <- news_tokens_pruned %>%
count(id, token) %>%
cast_sparse(id, token, n)
dim(counts_mx)[1] 3407 6299
Вторая матрица – это наши эмбеддинги в разреженном виде. Число столбцов соответствует числу сингулярных векторов, которое мы задали при факторизации.
embedding_mx <- word_emb %>%
cast_sparse(word, dimension, value)
dim(embedding_mx)[1] 6299 100
Перемножение матрицы \((3407 \times 6299)\) на \((6299 \times 20)\) вернет матрицу размером \((3407 \times 20)\), то есть мы получим эмбеддинги для документов.
doc_mx <- counts_mx %*% embedding_mx
dim(doc_mx)[1] 3407 100
13.8 Похожие документы
Превратим разреженную матрицу обратно в датафрейм.
doc_emb_long <- doc_mx |>
as.matrix() |>
as.data.frame() |>
rownames_to_column("doc") |>
pivot_longer(-doc, names_to = "dimension", values_to = "value")nearest_neighbors(doc_emb_long, "doc14", doc = TRUE)Познакомимся с соседями.
load("../data/news.Rdata")
news_2019 |>
mutate(id = paste0("doc", row_number())) |>
filter(id %in% c("doc14", "doc1", "doc1000", "doc1142")) |>
mutate(text = str_trunc(text, 70)) Релевантная выдача здесь – только первый документ. С этой задачей LSA в нашем случае справилась лучше. Посмотрим теперь на семантическое пространство слов. Для этого придется снизить размерность со 100 до 2 измерений.
13.9 2D-визуализации пространства слов
word_emb_mx <- as.matrix(embedding_mx)Для снижения размерности мы используем алгоритм UMAP. В отличие от PCA, он снижает размерность нелинейно, и в этом отношении похож на t-SNE.
library(uwot)
set.seed(02062024)
viz <- umap(word_emb_mx, n_neighbors = 15, n_threads = 2)Как видно по размерности матрицы, все наши слова вложены теперь в двумерное пространство.
dim(viz)[1] 6299 2
tibble(word = rownames(word_emb_mx),
V1 = viz[, 1],
V2 = viz[, 2]) |>
ggplot(aes(x = V1, y = V2, label = word)) +
geom_text(size = 2, alpha = 0.4, position = position_jitter(width = 0.5, height = 0.5)) +
annotate(geom = "rect", ymin = 1.5, ymax = 5.5, xmin = 1.5, xmax = 6.5, alpha = 0.2, color = "tomato")+
theme_light()
Посмотрим на выделенный фрагмент этой карты.
tibble(word = rownames(word_emb_mx),
V1 = viz[, 1],
V2 = viz[, 2]) |>
filter(V1 > 1.5 & V1 < 6.5) |>
filter(V2 > 1.5 & V2 < 5.5) |>
ggplot(aes(x = V1, y = V2, label = word)) +
geom_text(size = 2, alpha = 0.4, position = position_jitter(width = 0.5, height = 0.5)) +
theme_light()
13.10 Математическое волшебство
Если слова – это векторы, то их можно складывать и вычитать, как мы это делали в школе. Согласно известному примеру, если из вектора слова “король” вычесть вектор “мужчина” и прибавить вектор “женщина”, получатся числа, соответствующие слову “королева”. Правда, у нас слишком небольшой датасет, так что на озарения лучше не рассчитывать.
library(plyr)
mystery_word <- unrowname(word_emb_mx["спортсмен",] + word_emb_mx["мяч",])
head(mystery_word) 1 2 3 4 5 6
0.02899943 -0.01946437 0.12604935 0.02450570 -0.03578517 -0.01520623
mystery_tbl <- tibble(
word = "mystery",
dimension = as.numeric(names(mystery_word)),
value = as.numeric(mystery_word)
)word_emb_new <- word_emb |>
bind_rows(mystery_tbl)
nearest_neighbors(word_emb_new, "mystery")Отличная работа 🏈
13.11 Сглаженная PMI (DPF)
Как уже было сказано, для редких слов PMI оказывается завышена: т.к. вероятность встретить их в корпусе очень мала, знаменатель в формуле PMI тоже уменьшается. Иными словами, существует негативная корреляция между частотностью слова и его PMI.
Есть несколько способов если не совсем избавиться от негативной корреляции, то по крайней мере уменьшить ее абсолютное значение. Один из них заключается в том, чтобы чуть завысить знаменатель в формуле PMI, например, возведя его в дробную степень. Такой подход применялся в недавнем исследовании по цифровой истории идей, авторы которого предлагают использовать меру под названием DPF (Distributional Probability Factor). Показатель степени α при этом устанавливается в районе α = 0.75.
\[ DPF\ \left(x;y\right)=\frac{P\left(x,y\right)}{(P\left(x\right)P{\left(y\right))}^\alpha} \]
Второй способ известен как сглаживание Лапласа, оно же аддитивное сглаживание. В этом случае мы прибавляем единицу к частоте каждого слова, как будто видели его на один раз больше (этот способ задействуется и в некоторых алгоритмах машинного обучения).
Применять сглаживание функция pairwise_pmi() не умеет, поэтому на этот раз мы посчитаем взаимную информацию чуть иначе.
# вероятность для каждого слова
unigram_probs <- news_tokens_pruned |>
dplyr::count(token, sort = TRUE) |>
mutate(p = n / sum(n))
unigram_probs# вероятность встретить слова вместе
bigram_probs <- news_tokens_pruned |>
pairwise_count(token, id, diag = TRUE, sort = TRUE) |>
mutate(p = n / sum(n))
bigram_probs |>
arrange(-n) # на этом этапе можно отфильтровать n < 2Посчитаем нормализованную вероятность: это вероятность встретить два слова вместе, деленная на произведение вероятностей встретить каждое из них отдельно.
# взаимная информация
normalized_probs <- bigram_probs |>
# filter(n > 4) |>
dplyr::rename(word1 = item1, word2 = item2) |>
left_join(unigram_probs |>
select(word1 = token, p1 = p),
by = "word1") |>
left_join(unigram_probs %>%
select(word2 = token, p2 = p),
by = "word2") |>
mutate(p_together = p / p1 / p2)
normalized_probsРассчитаем заново PMI и добавим сглаживание.
# pmi & dpf
pmi_data <- normalized_probs |>
mutate(pmi = log(p_together)) |>
mutate(dpf = p / (p1 * p2)^0.75) |>
mutate(ppmi = case_when(pmi < 0 ~ 0,
.default = pmi))
pmi_data |>
arrange(pmi)Сравним зависимость от частотности в трех случаях.
library(ggpubr)
# корреляция
pmi_data |>
filter(word1 == "футболист") |>
filter(!word1 == word2) |>
select(word2, pmi, ppmi, dpf, p2) |>
pivot_longer(cols = c(pmi, dpf, ppmi), names_to = "measure", values_to = "value") |>
ggplot(aes(p2, value, color = value)) +
geom_jitter(width = 0.0002, height = 0.0002,
show.legend = FALSE, alpha = 0.5) +
xlim(NA, 0.0045) + # zoom in
facet_wrap(~ measure, scales = "free_y") +
stat_cor(aes(label = ..r.label..),
method = "pearson",
label.x = 0.002,
color = "tomato",
geom = "label"
) +
theme_bw() +
labs(title = "Корреляция между частотностью слова и PMI / DPF",
y = NULL)
Аналогично тому, что мы делали выше, можно снизить размерность DPF-матрицы при помощи SVD или же сохранить ее в разреженном виде для изучения совместной встречаемости слов.